Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ

specs/20260711-153545-message-markdown-styling/spec.md 212bc596e37fd45e75b830e36cd825972f12cf79 (212bc596) Text, 31.07 KB

Feature Specification: Message Inline Markdown Styling (iOS Parity)

Feature Branch: T383838claude/message-markdown-styling-parity (proposed)
Created: 2026-07-11
Status: Draft
Input: "Investigate the iOS message-input markdown styling feature and develop a parity spec for Android."
Cross-Platform Reference: meshtastic-apple PR #1771 β€” Add message formatting toolbar (iOS 18+)

Clarifications

Session 2026-07-11

β€’ Q: What scope should the first delivery cover? β†’ A: All three β€” P1 render, P2 authoring toolbar, and P3 draft styling β€” in one effort (staged internally P1β†’P2β†’P3, but not split across PRs).
β€’ Q: Which markdown parsing approach for the renderer? β†’ A: JetBrains T383838org.jetbrains:markdown (intellij-markdown) AST walk; add as a direct version-catalog + T383838core/ui dependency.
β€’ Q: How should the draft preview / live styling (P3) work? β†’ A: Live in-field styling via Compose T383838OutputTransformation (as the existing T383838mentionOutputTransformation does); no separate preview bubble unless in-field styling proves insufficient.
β€’ Q: How should markdown behave during active message search? β†’ A: Suppress markdown styling while a search query is active β€” render plain text + query highlight only.
β€’ Q: Should channel-URL (T383838meshtastic.org/e/#…) links tapped inside a bubble be intercepted (as iOS does)? β†’ A: No β€” out of scope; deferred to a separate story that also covers existing autolinks (default retained).

Summary

iOS renders and lets users author lightweight inline markdown in mesh text messages β€” bold, italic, ~~strikethrough~~, T383838inline code, and T383838LINK0 β€” plus automatic linkification of URLs, email addresses, phone numbers, and postal addresses. Android today renders none of the styling: it auto-links URLs/email/phone (via T383838AutoLinkText) but shows the raw delimiter characters (T383838**bold**, T383838~~strike~~) as literal text.

Because iOS transmits the raw markdown delimiters over the mesh, every Android user in a channel with an iOS 18+ user already receives messages full of literal asterisks and tildes today. Closing the render gap is therefore the higher-value, lower-risk half of parity. The authoring half (a formatting toolbar + live in-field styling over the compose field) matches iOS's editor.

This spec covers all three prioritized user stories β€” P1 render, P2 authoring toolbar, P3 draft styling β€” as a single delivery (per Clarifications). Priorities denote build/verification order within that effort, not separate PRs.

How iOS Works (reference behavior)

Two independent halves:

Rendering (all iOS versions). Message bubbles render T383838AttributedString(markdown:options:.init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) β€” inline styling only (no headers, lists, blockquotes, images), whitespace/newlines preserved. Parse failure falls back to plain text. Links are underlined and tinted. A derived, persisted field (T383838messagePayloadMarkdown) additionally auto-linkifies detected URLs / T383838tel: / T383838mailto: / T383838maps.apple.com?address= via T383838NSDataDetector, skipping matches already inside an existing T383838LINK0 link. Emoji-only messages skip markdown entirely.

Authoring (iOS 18+ / macOS 15+ only β€” gated on the new selection-observing T383838TextEditor).
β€’ A 5-button toolbar over the compose field: bold T383838**, italic T383838*, strikethrough T383838~~, code BT383838 BT383838, link [](url) Shown when the field is focused and holds β‰₯3 characters; buttons disabled when there is no text selection.
β€’ Toggle semantics: wrapping a selection that is already wrapped removes the delimiters; a collapsed cursor inserts an empty delimiter pair and places the caret between them. Wrapping trims whitespace so delimiters hug content, absorbs adjacent delimiter characters the selection cuts through, and cleans orphaned unpaired delimiters.
β€’ Link button opens a URL-entry alert and wraps the selection as T383838LINK0; re-invoking on an existing link unwraps it to plain display text.
β€’ A live preview bubble renders above the input showing the styled result, but only when the draft contains recognizable markdown syntax.
β€’ The message put on the wire is the raw draft text with literal delimiters β€” styling is presentation-only; there is no separate "formatted" payload transmitted.

Goals

1. Read parity (P1): Android renders inline markdown (bold, italic, strikethrough, inline code, links) in message bubbles so messages authored on iOS β€” which arrive with literal delimiters β€” display as intended.
2. Write parity (P2): Android offers an equivalent formatting toolbar over the compose field so Android users can author the same styling.
3. Preview parity (P3): Android shows a live styled preview of the draft, matching the iOS compose experience.
4. Preserve every existing messaging behavior: autolinking, T383838@-mention resolution, search highlighting, reply snippets, byte-limit enforcement, reactions.
5. Keep the rendering logic in shared T383838commonMain so Android and desktop (and any future iOS-KMP) benefit from one implementation.

Non-Goals

β€’ Block-level markdown (headers, lists, blockquotes, tables, images, code fences). iOS is inline-only; Android matches that. The T383838com.mikepenz:multiplatform-markdown-renderer already in the catalog is a block renderer used by docs/firmware/settings and is the wrong tool here β€” it does not compose with per-span links/mentions/selection inside a chat bubble.
β€’ Changing the wire format. Android continues to send exactly what the user typed (raw delimiters), matching iOS. No new protobuf fields, no T383838messagePayloadMarkdown-style transmitted payload.
β€’ A stored/derived markdown column in the database. Autolinking already happens at render time in T383838AutoLinkText; markdown parsing joins it there rather than being precomputed and persisted.
β€’ Rich-text WYSIWYG editing (the compose field still shows raw delimiters as you type, exactly like iOS). The toolbar only inserts/removes delimiter characters.
β€’ Reconciling the "non-markdown clients see literal T383838**" tradeoff β€” this is inherent to matching iOS and to the firmware/other-client ecosystem, and is out of scope to change unilaterally.

User Scenarios & Testing (mandatory)

User Story 1 β€” Render inline markdown in received & sent messages (Priority: P1)

As an Android user in a channel with iOS users, I see bold, italic, ~~strikethrough~~, T383838code, and links rendered with styling instead of raw T383838**/T383838*/T383838~~/BT383838 BT383838/[](…)characters.

Why this priority: Highest value, lowest risk, independently shippable. iOS 18+ users are already emitting markdown onto the mesh; this is a pure display fix in shared code with no wire/DB/compose changes. Delivers cross-client parity by itself.

Independent Test: Inject (or receive) a message T383838Meet at **noon** by the ~~old~~ new *bridge* β€” see \README\T383838 and LINK0. The bubble renders bold "noon", struck "old", italic "bridge", monospaced "README", and a tappable "map" link; no literal delimiter characters remain.

Acceptance Scenarios:

1. Given a message T383838**bold**, When rendered in a bubble, Then "bold" is FontWeight.Bold and the asterisks are not shown.
2. Given T383838*italic*, Then "italic" is italicized; Given T383838~~strike~~, Then "strike" has line-through; Given BT383838 codeT383838 Then "code" uses a monospace family.
3. Given T383838LINK0, Then "label" renders as a styled, tappable link to T383838https://example.com and the bracket/paren syntax is not shown.
4. Given a bare URL, email, or phone number (no markdown syntax), Then existing autolink behavior is unchanged.
5. Given a message that mixes a markdown link and a bare URL and an T383838@!<hex> mention, Then all three resolve correctly with no overlapping or mis-offset spans.
6. Given malformed/unpaired delimiters (T383838**oops, T383838a * b * c), Then the text renders sanely (literal fallback for that fragment) and never crashes.
7. Given an emoji-only message, Then it renders unchanged (no markdown processing).
8. Given search mode is active, Then query highlighting still applies and markdown styling is suppressed (plain text + highlight only β€” see FR-009).


User Story 2 β€” Format a draft with a toolbar (Priority: P2)

As an Android user composing a message, I can select text and tap Bold / Italic / Strikethrough / Code / Link to wrap it in the corresponding markdown, or tap again to remove it.

Why this priority: Completes authoring parity, but is larger surface area (text mutation, selection math, edge cases) and depends on P1 to make its output legible. Android's T383838TextFieldState exposes T383838selection: TextRange and T383838edit { } on all OS versions, so β€” unlike iOS β€” no version gate is required.

Independent Test: Type "hello world", select "world", tap Bold β†’ field shows "hello world" with "world" (plus delimiters) selected; tap Bold again β†’ back to "hello world".

Acceptance Scenarios:

1. Given a non-empty selection, When a style button is tapped, Then the selection is wrapped with that style's delimiters and remains selected (including the delimiters).
2. Given an already-wrapped selection, When the same button is tapped, Then the delimiters are removed (toggle off).
3. Given a collapsed cursor, When a style button is tapped, Then an empty delimiter pair is inserted and the caret sits between them.
4. Given the Link button with a selection, Then a URL-entry dialog appears and Insert wraps the selection as T383838LINK0; Given an existing markdown link is selected, Then the button unwraps it.
5. Given no selection exists, Then the wrap/toggle buttons are disabled (collapsed-cursor insert still allowed, matching the collapsed-cursor path).
6. Given wrapping would push the message over the 200-byte limit, Then the same over-limit affordance that governs Send applies (see FR-014).
7. Given a selection whose boundaries cut through existing delimiters, When wrapped, Then no orphaned unpaired delimiter characters are left behind.


User Story 3 β€” Live in-field styling of the draft (Priority: P3)

As an Android user typing markdown, I see my formatting styled live in the compose field, so I can confirm it before sending.

Why this priority: Rounds out parity; depends on P1's renderer and P2's field wiring. Built last within the single delivery.

Independent Test: Type T383838see **this**; "this" renders bold within the text field as you type (delimiters shown per the OutputTransformation styling rules); clearing the markdown returns the field to plain text.

Acceptance Scenarios:

1. Given the draft contains recognizable markdown syntax, When typing, Then the field applies the corresponding styling live via T383838OutputTransformation without mutating the stored draft.
2. Given the draft contains no markdown syntax, Then the field renders as plain text.
3. Given live in-field styling is applied, Then the underlying T383838TextFieldState.text (and therefore the bytes sent) is unchanged β€” styling is display-only.


Edge Cases

β€’ Unpaired / nested / adjacent delimiters (T383838***bold-italic***, T383838**a*b**, T383838a**b): must not crash; render best-effort, preferring literal fallback over throwing.
β€’ Delimiters inside code spans (BT383838 abcT383838 : content inside inline code should not be re-interpreted as italic/bold (matches CommonMark inline precedence; iOS's parser handles this).
β€’ Markdown link whose URL is also a channel URL (T383838meshtastic.org/e/#…): today Android does not intercept channel URLs tapped inside a bubble (iOS does). This spec does not add that interception (see Assumptions); the link opens as a normal external URL unless a separate story adds interception.
β€’ Mention token adjacent to a delimiter (T383838**@!abcd1234**): mention substitution and markdown span application must both land on correct offsets.
β€’ Very long messages near 200 bytes where delimiters consume budget: byte counting already counts the raw delimiters (they are on the wire), so no special handling needed beyond existing enforcement.
β€’ RTL / bidi text with delimiters: rendering must not corrupt bidi runs.
β€’ Homoglyph optimization on the input path must continue to operate on the raw text.

Architecture

Key Components

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”
β”‚ Component β”‚ Module / File β”‚ Ch… β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€
β”‚ Inline markdown renderer β”‚ T383838core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/compo… β”‚ Ex… β”‚
β”‚ Markdown parsing helper β”‚ T383838core/ui/src/commonMain/.../component/ (new, e.g. T383838InlineMar… β”‚ Pu… β”‚
β”‚ Message bubble β”‚ T383838feature/messaging/.../component/MessageItem.kt β”‚ No… β”‚
β”‚ Formatting helpers (P2) β”‚ T383838feature/messaging/.../ or T383838core/ui (new, e.g. T383838MessageFormat… β”‚ Pu… β”‚
β”‚ Formatting toolbar (P2) β”‚ T383838feature/messaging/.../component/ (new, e.g. T383838FormattingTool… β”‚ Co… β”‚
β”‚ Live in-field styling (P3) β”‚ T383838feature/messaging/.../Message.kt (T383838OutputTransformation) β”‚ Ch… β”‚
β”‚ Compose field wiring (P2) β”‚ T383838feature/messaging/.../Message.kt (T383838MessageInput, ~549–661; T383838… β”‚ Ho… β”‚
β”‚ ~~Live preview bubble (P3)~~ β”‚ β€” β”‚ Su… β”‚
β”‚ Icons β”‚ T383838core/ui MeshtasticIcons β”‚ Bo… β”‚
β”‚ Strings β”‚ T383838core/resources/.../composeResources/values/strings.xml β”‚ To… β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”˜

Rendering pipeline (P1) β€” offset ordering

The existing pipeline substitutes T383838@!<hex> mentions into display names (shifting offsets) and then runs URL/email/phone regex over the substituted display text, tracking T383838usedIndices to prevent overlap. Inline markdown must slot into this offset-shift chain:

T282828
raw wire text
β†’ (a) substitute mentions to display names [existing: shifts offsets]
β†’ (b) parse & strip inline-markdown delimiters, [NEW: shifts offsets, records styled + link spans]
recording styled ranges and [text](url) links
β†’ (c) run URL/email/phone autolink over the [existing, now over the stripped text]
stripped display text, skipping usedIndices
β†’ build AnnotatedString: apply mention links,
markdown style spans, markdown link spans,
autolink spans β€” all in display-space offsets

The correctness-critical requirement is that steps (a)+(b) produce a single consistent display string with an accurate raw→display index map, so every later span lands on the right characters. Prefer one linear tokenizer pass that emits the display string plus spans, rather than layering regex replacements.

Why not the block markdown renderer

T383838multiplatform-markdown-renderer renders a whole T383838Markdown(...) Composable subtree (paragraph/heading/list layout). It cannot (a) restrict to inline-only cleanly, (b) share the bubble's selection/context-menu/tapback surface, or (c) interleave T383838@-mention T383838LinkAnnotation.Clickable spans. The T383838AnnotatedString approach in T383838AutoLinkText already does links+mentions+highlight; markdown is the natural next span source there.

Implementation Options / Libraries

Everything below is KMP-compatible (T383838commonMain); the shared-code goal rules out Android-only APIs. There is no T383838commonMain system API equivalent to iOS's T383838AttributedString(markdown:) β€” that is platform Foundation β€” so a parser (library or hand-rolled) is unavoidable on the KMP side.

Rendering β€” turning the raw string into styled spans (P1)

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”
β”‚ Option β”‚ What it is β”‚ Tr… β”‚ Ve… β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€
β”‚ JetBrains T383838org.jetbrains:markdown (intellij-markdown) β”‚ Pure-Kotlin multipla… β”‚ Re… β”‚ βœ…β€¦ β”‚
β”‚ Hand-rolled inline tokenizer β”‚ A single linear pass… β”‚ Ze… β”‚ No… β”‚
β”‚ ~~T383838multiplatform-markdown-renderer (block T383838Markdown())~~ β”‚ Block-level Composab… β”‚ Wr… β”‚ Re… β”‚
β”‚ ~~T383838HtmlCompat.fromHtml / T383838Linkify / T383838AnnotatedString.fromHtml~~ β”‚ Android-platform HTM… β”‚ An… β”‚ Re… β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”˜

Authoring β€” live styling and the toolbar (P2/P3)

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”
β”‚ Option β”‚ Wh… β”‚ Tr… β”‚ Ve… β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€
β”‚ Compose T383838OutputTransformation (T383838BasicTextField/T383838TextFieldState, "BasicTextField2… β”‚ Sy… β”‚ Co… β”‚ βœ…β€¦ β”‚
β”‚ T383838TextFieldState.edit { } + pure helper functions β”‚ Pr… β”‚ No… β”‚ Re… β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”˜

Requirements (mandatory)

Functional Requirements β€” Rendering (P1)

β€’ FR-001: Message bubbles MUST render T383838**bold** as bold, T383838*italic* (single-asterisk, not part of T383838**) as italic, T383838~~strike~~ with line-through, and BT383838 codeT383838 in a monospace family, with the delimiter characters removed from the displayed text.
β€’ FR-002: Message bubbles MUST render T383838LINK0 as a styled, tappable link on "label" to T383838url, with the markdown syntax removed, using the same link styling/click handling as existing autolinks.
β€’ FR-003: Existing autolinking of bare URLs, emails, and phone numbers MUST continue to work, and MUST NOT double-link a URL already expressed as a markdown link.
β€’ FR-004: T383838@!<hex> mention resolution/styling/click MUST continue to work and MUST remain correctly positioned when combined with markdown styling.
β€’ FR-005: Malformed or unpaired markdown MUST degrade gracefully to literal text for the affected fragment and MUST NOT throw.
β€’ FR-006: Emoji-only messages MUST bypass markdown processing (parity with iOS T383838isEmoji guard).
β€’ FR-007: Newlines and internal whitespace MUST be preserved (parity with T383838inlineOnlyPreservingWhitespace).
β€’ FR-008: Block markdown (headers, lists, blockquotes, fenced code, images, tables) MUST NOT be interpreted; such syntax renders literally.
β€’ FR-009: Search-highlight mode MUST continue to highlight query matches. Markdown styling MUST be suppressed while a search query is active β€” the bubble renders plain text plus query highlight only (per Clarifications), avoiding offset conflicts between highlight spans and delimiter stripping.
β€’ FR-010: The renderer MUST live in T383838commonMain so desktop shares it. The parsing engine MUST be the JetBrains T383838org.jetbrains:markdown (intellij-markdown) multiplatform CommonMark parser (AST-walk emitting inline spans), added as a direct version-catalog + T383838core/ui dependency (per Clarifications). Block Composable renderers and Android-only HTML/Linkify APIs are excluded (see Implementation Options).

Functional Requirements β€” Authoring toolbar (P2)

β€’ FR-011: A formatting toolbar MUST offer Bold, Italic, Strikethrough, Code, and Link actions that insert/remove the corresponding delimiters on the current T383838TextFieldState selection (via T383838TextFieldState.edit { } + pure helpers). Live in-field styling of the draft SHOULD use Compose T383838OutputTransformation (as the existing T383838mentionOutputTransformation does).
β€’ FR-012: Each style action MUST toggle: wrap an unwrapped selection, unwrap an already-wrapped one; a collapsed cursor MUST insert an empty delimiter pair with the caret placed between the delimiters.
β€’ FR-013: The Link action MUST prompt for a URL and wrap the selection as T383838LINK0 (or insert a T383838LINK1 placeholder when the cursor is collapsed), and MUST unwrap an already-linked selection.
β€’ FR-014: Byte-limit enforcement MUST remain authoritative β€” a formatting action that would exceed 200 bytes is subject to the same over-limit handling that already governs Send (no silent truncation of user content mid-delimiter).
β€’ FR-015: Wrap actions MUST be disabled when there is no selection; toolbar visibility SHOULD follow focus and a minimum draft length (parity: β‰₯3 chars) β€” exact trigger is a UX detail, not a hard requirement.
β€’ FR-016: Wrapping MUST trim surrounding whitespace so delimiters hug content, absorb adjacent delimiter characters, and remove orphaned unpaired delimiters (parity with iOS T383838MarkdownFormatting).
β€’ FR-017: All formatting-logic functions MUST be pure and unit-tested in T383838commonTest (no Compose/Android deps), mirroring the iOS helper's testability.

Functional Requirements β€” Draft styling (P3)

β€’ FR-018: The user MUST be able to see how the draft will be styled before sending, via live in-field styling of the compose field using Compose T383838OutputTransformation (following the existing T383838mentionOutputTransformation), per Clarifications. A separate preview bubble is NOT required unless in-field styling proves insufficient in practice.

Non-Functional Requirements

β€’ NFR-001: No new protobuf fields and no change to transmitted bytes β€” the wire payload is the user's raw text (parity with iOS).
β€’ NFR-002: Accessibility β€” toolbar buttons MUST have content descriptions (localized); styled text MUST remain selectable/announced by TalkBack; links MUST be reachable.
β€’ NFR-003: Localization β€” all new user-facing strings via T383838stringResource(Res.string.…); run T383838python3 scripts/sort-strings.py after adding keys.
β€’ NFR-004: Performance β€” annotated-string construction stays within the existing T383838remember(text, …) cache in T383838AutoLinkText; parsing is a single linear pass, no per-frame regex storms.
β€’ NFR-005: Rendering MUST not regress existing messaging behaviors (reply snippets, reactions, status icons, paging).
β€’ NFR-006: Design-standard alignment β€” toolbar uses M3 components and T383838MeshtasticIcons; link/style colors reuse existing tokens (T383838HyperlinkBlue, monospace via theme).

Source-Set Impact

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Source Set β”‚ Im… β”‚ Justification β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ T383838commonMain (T383838core/ui) β”‚ Mo… β”‚ Shared render path used by Android and desktop β”‚
β”‚ T383838commonMain (T383838feature/messaging) β”‚ Ad… β”‚ Compose UI + text-mutation logic is shared β”‚
β”‚ T383838commonTest β”‚ Ad… β”‚ Pure functions, high-value coverage β”‚
β”‚ T383838commonMain (T383838core/resources) β”‚ Ad… β”‚ Accessibility labels + link dialog β”‚
β”‚ T383838androidMain / T383838jvmMain β”‚ No… β”‚ No platform-specific code; T383838TextFieldState/T383838AnnotatedStrin… β”‚
β”‚ T383838core/proto β”‚ No… β”‚ Wire format unchanged (read-only upstream regardless) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Design Standards Compliance

β€’ [ ] Toolbar reviewed against design standards β€” new component; verify against T383838.skills/design-standards and upstream T383838meshtastic/design
β€’ [ ] M3 component selection verified β€” icon buttons, dialog
β€’ [ ] Accessibility: TalkBack semantics on toolbar + styled/linked text, 44dp touch targets, color-independent affordances
β€’ [ ] Typography: inline T383838code monospace family sourced from theme, not hardcoded
β€’ [x] No new screens β€” changes are within the existing messaging screen and shared text component

Privacy Assessment

β€’ [x] No PII, location, or cryptographic keys logged or exposed
β€’ [x] No new network calls; no data transmitted beyond the existing message payload
β€’ [x] T383838core/proto not modified (read-only upstream)
β€’ [x] Markdown is presentation-only; message content on the wire is unchanged from what the user typed

Success Criteria (mandatory)

Measurable Outcomes

β€’ SC-001: A message authored on iOS with bold/italic/strikethrough/code/link renders on Android with the same styling and no visible delimiter characters (P1).
β€’ SC-002: Bare URLs, emails, phone numbers, and T383838@-mentions continue to render and behave exactly as before (zero regression) (P1).
β€’ SC-003: Malformed markdown never crashes and degrades to readable text across a fuzz set of unpaired/nested/adjacent delimiters (P1).
β€’ SC-004: An Android user can produce, via the toolbar, a message byte-for-byte identical to what iOS's toolbar would produce for the same selection and action (P2).
β€’ SC-005: Formatting helpers and the inline parser have unit tests covering wrap/toggle/insert/link/orphan-cleanup and each style, all in T383838commonTest (P1+P2).
β€’ SC-006: Desktop renders the same inline styling as Android from the shared T383838commonMain renderer (P1).
β€’ SC-007: Baseline verification passes: T383838./gradlew spotlessApply spotlessCheck detekt assembleDebug test allTests (+ T383838kmpSmokeCompile for touched KMP modules).

Assumptions

β€’ Inline markdown rendering belongs in T383838core/ui/AutoLinkText.kt (extending T383838buildAnnotatedStringWithLinks), not the block T383838multiplatform-markdown-renderer, which is inappropriate for chat bubbles.
β€’ If the JetBrains T383838org.jetbrains:markdown parser is chosen, it must be added as a direct version-catalog entry and a T383838core/ui dependency β€” it is currently only a transitive dependency of the block T383838multiplatform-markdown-renderer (which T383838core/ui/T383838feature/messaging do not depend on), so it cannot be relied upon implicitly.
β€’ Android continues to transmit the raw draft (literal delimiters), matching iOS; the "non-markdown clients see T383838**" tradeoff is accepted as inherent to parity and out of scope to change here.
β€’ No stored/derived markdown DB column is added; parsing happens at render time alongside existing autolinking.
β€’ Search mode may suppress markdown styling to keep highlight offsets simple (FR-009) β€” treated as an acceptable, documented divergence from iOS.
β€’ Channel-URL (T383838meshtastic.org/e/#…) interception on links tapped inside a bubble is not added by this spec (Android doesn't do it today for autolinks either); if desired it is a separate story spanning both markdown links and existing autolinks.
β€’ Android does not need iOS's version gate: T383838TextFieldState (already used by T383838MessageInput) exposes selection and T383838edit { } on all supported versions, so the toolbar can ship without an OS floor.
β€’ The inline T383838code monospace family is available via the Compose Multiplatform theme; if not, a bundled monospace resource is a small add.
β€’ Bold/Italic/Strikethrough/Code/Link icons exist in T383838MeshtasticIcons or are trivially added.
β€’ CommonMark inline precedence (e.g. no re-interpretation inside code spans, T383838**-before-T383838*) is the target semantics; exact parser is an implementation choice, but its behavior must match the acceptance scenarios and the iOS-authored corpus.

Resolved Decisions

All prior open questions were resolved in the 2026-07-11 clarification session (see Clarifications):

1. Scope β€” P1 + P2 + P3 delivered together (staged internally by priority).
2. Search + styling β€” markdown styling suppressed during active search (FR-009).
3. Parser strategy β€” JetBrains T383838org.jetbrains:markdown (intellij-markdown), added as a direct dependency (FR-010).
4. Draft styling β€” live in-field styling via T383838OutputTransformation; no separate preview bubble unless it proves insufficient (FR-018).
5. Channel-URL interception inside bubbles β€” out of scope; deferred to a separate story covering existing autolinks too.

Served by rngit 1.5.4 - Generated in 0.29s